Go와 Redis를 이용한 슬라이딩 윈도우 속도 제한기 구현
Grace Collins
Solutions Engineer · Leapcell

소개
끊임없이 발전하는 분산 시스템 및 마이크로서비스 환경에서 API 트래픽을 효과적으로 관리하는 것은 시스템 안정성 유지, 악용 방지 및 리소스의 공정한 분배를 보장하는 데 매우 중요합니다. 제한 없이 요청하면 서버가 과부하되고 서비스 거부 공격으로 이어지며 사용자 경험이 저하될 수 있습니다. 이곳에서 속도 제한이 중요한 방어 메커니즘으로 등장합니다. 다양한 속도 제한 알고리즘 중에서 슬라이딩 윈도우 접근 방식은 고정 윈도우나 토큰 버킷과 같은 단순한 metod보다 요청 속도를 더 정확하고 반응적으로 제어하는 방법을 제공합니다. 이 문서는 Go의 동시성 기능과 성능, 그리고 속도 제한과 같이 고성능 사용 사례에 이상적인 강력한 인메모리 데이터 저장소인 Redis를 사용하여 슬라이딩 윈도우 속도 제한기를 구현하는 것을 살펴볼 것입니다. 이 강력한 기법의 작동 방식을 자세히 알아보고 구현을 시연하기 위한 실제 Go 코드 예제를 제공할 것입니다.
핵심 개념 이해
구현에 들어가기 전에 관련 핵심 개념에 대한 명확한 이해를 확립해 보겠습니다.
- 속도 제한(Rate Limiting): 주어진 시간 프레임 내에서 사용자 또는 클라이언트가 서버에 보낼 수 있는 요청 수를 제한하는 제어 메커니즘입니다. 주요 목표는 리소스 고갈을 방지하고, 악의적인 활동으로부터 보호하며, 공정한 사용을 보장하는 것입니다.
- 슬라이딩 윈도우 알고리즘(Sliding Window Algorithm): 연속적으로 이동하는 시간 윈도우에서 요청을 추적하는 속도 제한 알고리즘입니다. 고정 윈도우 알고리즘과 달리 윈도우 경계에서 "버스트" 문제가 발생할 수 있는 반면, 슬라이딩 윈도우는 더 부드럽고 정확한 속도 제어를 제공합니다. 일반적으로 현재 윈도우와 이전 윈도우의 두 가지 고정 윈도우를 결합합니다. 현재 윈도우의 카운트는 해당 윈도우의 통과한 부분만큼 가중치가 부여되고, 이전 윈도우의 카운트는 해당 윈도우의 남아있는 관련 부분만큼 가중치가 부여됩니다.
- Go(Golang): Google에서 설계한 정적 타입의 컴파일 언어입니다. 동시성 기본 기능(고루틴 및 채널), 가비지 컬렉션 및 강력한 표준 라이브러리가 뛰어나 고성능 네트워크 서비스를 구축하는 데 이상적인 선택입니다.
- Redis: 데이터베이스, 캐시 및 메시지 브로커로 사용되는 오픈 소스 인메모리 데이터 구조 저장소입니다. 번개처럼 빠른 읽기/쓰기 속도와 함께 정렬된 집합 및 해시 맵과 같은 다양한 데이터 구조를 지원하므로 속도 제한기 구현에 매우 적합합니다.
슬라이딩 윈도우 속도 제한기 설명
슬라이딩 윈도우 속도 제한기의 핵심 아이디어는 타임스탬프별로 요청을 추적하는 것입니다. 요청이 도착하면 해당 타임스탬프가 데이터 구조에 추가됩니다. 그런 다음 요청을 허용해야 하는지 여부를 결정하기 위해 정의된 시간 윈도우(예: 지난 60초) 내에 속하는 요청 수를 계산합니다. 중요하게도 시간이 지남에 따라 윈도우 외부로 들어가는 오래된 요청은 자동으로 삭제됩니다.
Redis의 **정렬된 집합(Sorted Sets)**은 이러한 용도에 완벽하게 적합합니다. 각 요청의 타임스탬프는 점수로 저장될 수 있으며, 고유 식별자(예: UUID 또는 단순히 타임스탬프 자체)는 멤버로 저장될 수 있습니다. 이를 통해 다음을 효율적으로 수행할 수 있습니다.
- 새 요청 타임스탬프 추가:
ZADD key timestamp timestamp - 오래된 요청 제거:
ZREMRANGEBYSCORE key -inf (now - windowDuration) - 현재 요청 수 계산:
ZCARD key또는ZCOUNT key (now - windowDuration) +inf
Go 및 Redis를 사용한 구현 세부 사항
Go 및 Redis 구현을 살펴보겠습니다.
먼저 Go용 Redis 클라이언트가 필요합니다. go-redis/redis/v8 패키지는 인기 있고 강력한 선택입니다.
package main import ( "context" "fmt" "log" "strconv" "time" "github.com/go-redis/redis/v8" ) // RateLimiterConfig는 속도 제한기의 구성을 보유합니다. type RateLimiterConfig struct { Limit int // 최대 허용 요청 수 WindowSize time.Duration // 슬라이딩 윈도우의 기간 RedisClient *redis.Client } // NewRateLimiterConfig는 새 RateLimiterConfig 인스턴스를 생성합니다. func NewRateLimiterConfig(limit int, windowSize time.Duration, rdb *redis.Client) *RateLimiterConfig { return &RateLimiterConfig{ Limit: limit, WindowSize: windowSize, RedisClient: rdb, } } // Allow는 슬라이딩 윈도우 알고리즘을 기반으로 요청이 허용되는지 확인합니다. func (rlc *RateLimiterConfig) Allow(ctx context.Context, key string) (bool, error) { now := time.Now().UnixNano() / int64(time.Millisecond) // 밀리초 단위의 현재 타임스탬프 // 원자성을 위해 Redis 트랜잭션(MULTI/EXEC) 사용 // 이 트랜잭션은 모든 작업을 단일 원자 단위로 처리하도록 보장합니다. pipe := rlc.RedisClient.Pipeline() // 1. 윈도우보다 오래된 타임스탬프 제거 // ZREMRANGEBYSCORE key -inf (now - windowSizeInMilliseconds) // 점수 앞의 `(`는 해당 점수를 제외함을 의미합니다. 윈도우 시작보다 *엄격히 오래된* 요소를 제거하려고 합니다. minScore := now - rlc.WindowSize.Milliseconds() pipe.ZRemRangeByScore(ctx, key, "-inf", strconv.FormatInt(minScore, 10)) // 2. 현재 요청 타임스탬프 추가 // ZADD key now now // 여기서 점수와 멤버는 동일합니다(타임스탬프). pipe.ZAdd(ctx, key, &redis.Z{ Score: float64(now), Member: now, }) // 3. 현재 윈도우의 요청 수 계산 // ZCOUNT key (now - windowSizeInMilliseconds) +inf // 새로 추가된 것을 포함하여 현재 윈도우 내의 모든 요소 수를 계산합니다. countCmd := pipe.ZCount(ctx, key, strconv.FormatInt(minScore, 10), "+inf") // 4. 키가 무한히 커지는 것을 방지하기 위해 만료 설정 // 트래픽이 중단되는 키에 중요합니다. // 윈도우 내의 레코드를 항상 사용할 수 있도록 윈도우 크기보다 약간 길게 설정하고 약간의 버퍼를 추가합니다. pipe.Expire(ctx, key, rlc.WindowSize+10*time.Second) // 안전을 위해 버퍼 추가 // 파이프라인의 모든 명령을 원자적으로 실행 _, err := pipe.Exec(ctx) if err != nil { return false, fmt.Errorf("redis transaction failed: %w", err) } // 실행된 명령에서 카운트 가져오기 currentRequests, err := countCmd.Result() if err != nil { return false, fmt.Errorf("failed to get request count from redis: %w", err) } return int(currentRequests) <= rlc.Limit, nil } func main() { // Redis 클라이언트 초기화 rdb := redis.NewClient(&redis.Options{ Addr: "localhost:6379", // Redis 주소로 교체하세요 Password: "", // 비밀번호 없음 DB: 0, // 기본 DB 사용 }) // 연결 확인을 위해 Redis에 Ping ctx := context.Background() _, err := rdb.Ping(ctx).Result() if err != nil { log.Fatalf("Could not connect to Redis: %v", err) } fmt.Println("Connected to Redis!") // 속도 제한기 구성: 고유 키당 10초당 5회 요청 limiter := NewRateLimiterConfig(5, 10*time.Second, rdb) // 특정 사용자 ID 또는 API 키에 대한 요청 시뮬레이션 userID := "user:123" fmt.Printf("Rate limit for %s: %d requests per %s\n", userID, limiter.Limit, limiter.WindowSize) for i := 1; i <= 10; i++ { allowed, err := limiter.Allow(ctx, userID) if err != nil { log.Printf("Error checking rate limit for %s: %v", userID, err) time.Sleep(500 * time.Millisecond) // 오류 발생 시 과도한 요청 방지 continue } if allowed { fmt.Printf("Request %d for %s: ALLOWED\n", i, userID) } else { fmt.Printf("Request %d for %s: BLOCKED (Rate Limit Exceeded)\n", i, userID) } time.Sleep(500 * time.Millisecond) // 요청 간 약간의 지연 시뮬레이션 if i == 5 { fmt.Println("\n--- Waiting for window to slide ---") time.Sleep(6 * time.Second) // 요청이 다시 허용되는 것을 보기 위해 몇 초간 기다립니다. fmt.Println("--- Continuing requests ---") } } }
코드 설명:
RateLimiterConfig구조체: 이 구조체는Limit(최대 허용 요청 수)과WindowSize(예: 10초) 및RedisClient인스턴스를 보유합니다.Allow(ctx context.Context, key string) (bool, error)메소드: 이것이 핵심 로직입니다.now: Redis 정렬된 집합 멤버의 점수로 사용되는 밀리초 단위의 현재 타임스탬프를 가져옵니다.- Redis 파이프라인(트랜잭션): 중요하게도 Redis
Pipeline을 사용하여 원자성을 보장합니다. 이는ZREMRANGEBYSCORE,ZADD,ZCOUNT및EXPIRE명령을 Redis 서버로 한 번의 왕복으로 묶습니다. 이는 모든 작업이 Redis 관점에서 순차적이고 원자적으로 실행되며 읽기 및 쓰기 사이에 카운트가 잘못될 수 있는 경쟁 조건을 방지하도록 보장합니다. ZRemRangeByScore: 이 명령은 점수가now - windowSize보다 작거나 같은 모든 멤버를key정렬된 집합에서 제거합니다. 이렇게 하면 현재 슬라이딩 윈도우 내에 더 이상 없는 이전 요청이 효과적으로 가지치기됩니다.ZAdd:now타임스탬프를 정렬된 집합의 점수와 멤버로 모두 추가합니다. 점수가 타임스탬프이므로 시간별로 정렬하고 필터링할 수 있습니다.ZCount: 이 명령은 점수가(now - windowSize)에서+inf범위 내에 있는key정렬된 집합의 멤버 수를 계산합니다. 이렇게 하면 현재 슬라이딩 윈도우 내의 총 요청 수가 제공됩니다.Expire: Redis 키에 만료를 설정합니다. 이는 트래픽이 중단될 수 있는 키에 대한 중요한 최적화입니다. 만료가 없으면 사용되지 않는 속도 제한 키가 Redis 메모리에 무기한 누적됩니다. 윈도우 크기보다 약간 길게 설정하여 이전 윈도우의 끝에 있는 요청도 윈도우가 슬라이드될 때 올바르게 계산될 만큼 충분히 오래 남아 있도록 보장합니다.Exec: 파이프라인의 모든 명령을 실행합니다.currentRequests <= rlc.Limit: 마지막으로 계산된 요청 수를 구성된Limit와 비교하여 들어오는 요청을 허용할지 또는 차단할지 결정합니다.
적용 시나리오
슬라이딩 윈도우 속도 제한기는 매우 다재다능하며 다양한 시나리오에 적용할 수 있습니다.
- API 게이트웨이 보호: 클라이언트당 요청 수를 제한하여 백엔드 서비스가 과부하되는 것을 방지합니다.
- 사용자별 스로틀링: 단일 사용자가 너무 많은 요청(예: 너무 많은 로그인 시도, 너무 많은 검색 쿼리)을 보내는 것을 방지합니다.
- DDOS 방지: 비정상적으로 많은 요청을 보내는 IP 주소를 차단하여 볼륨 공격에 대한 첫 번째 방어선 역할을 합니다.
- 리소스 공정 사용: 속도 제한을 준수하는 사용자를 우선적으로 처리하여 모든 사용자에게 제한된 리소스의 공정한 몫을 보장합니다.
- 청구 및 계층별 서비스: 다른 구독 계층에 대해 서로 다른 속도 제한 구현(예: 무료 계층은 분당 100회 요청, 프리미엄 계층은 분당 1000회 요청).
결론
Go와 Redis를 사용하여 슬라이딩 윈도우 속도 제한기를 구현하는 것은 API 트래픽을 관리하는 매우 효과적이고 효율적인 방법을 제공합니다. Redis의 정렬된 집합과 Go의 동시성 기능을 활용하여 연속 시간 윈도우에서 요청을 정확하게 추적하고 리소스 고갈을 방지하며 시스템 안정성을 보장하는 견고한 시스템을 구축할 수 있습니다. 이 접근 방식은 단순한 metod에 비해 우수한 공정성과 정확성을 제공하여 복원력 있는 분산 애플리케이션을 설계하는 데 필수적인 도구입니다.

